누수는 “언젠가” 가 아니라 “항상 실패 경로에서” 난다.
이 드릴은 실패 경로만 집중적으로 친다.
이 드릴의 문서 구성 (D0~D9 공통)
① 요구사항서 — 무엇을 만드나, 표로 확정한다.
② pseudo code 템플릿 — 보고 치는 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 이게 통과하면 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 문제가 바뀌는 게 아니라 같은 모양을 다른 소재로 다시 치는 것이다.
각 변형마다 ①②③이 모두 준비되어 있다.
0. 이 드릴로 체화할 것
int f(const in_t *in, out_t *out){ int ret = RET_FAIL; /* ① 실패가 기본값. 성공은 맨 끝에서만 덮어쓴다 */ char *buf = NULL; /* ② 포인터는 전부 NULL, fd 는 -1 로 선언 */ FILE *fp = NULL; int fd = -1; CHK_PTR(in); /* ③ 자원 잡기 전 → 즉시 return (goto 아님) */ CHK_PTR(out); memset(out, 0x00, sizeof(*out)); buf = calloc(1, N); CHK_ALLOC_GOTO(buf, ret, CLEANUP); /* ④ 첫 자원을 잡은 뒤부터는 전부 goto (할당 실패는 NO_MEM) */ ret = RET_OK; /* ⑤ 끝까지 왔을 때만 성공 */CLEANUP: SAFE_FCLOSE(fp); /* ⑥ 잡은 역순 */ SAFE_FREE(buf); SAFE_CLOSE(fd); if (ret != RET_OK) memset(out, 0x00, sizeof(*out)); /* ⑦ 실패면 출력을 비운다 */ return ret;}
①②⑤⑥이 한 세트다. 하나만 빠져도 누수 아니면 double free 가 된다.
소유권 규칙 — create / destroy
상황
누가 해제하나
_create() 가 RET_OK
호출부가 _destroy() 해야 한다
_create() 가 실패
내부에서 전부 정리하고*out_obj = NULL 로 둔다. 호출부는 아무것도 안 한다
_destroy(NULL)
합법이어야 한다. 아무 일도 안 하고 리턴
_destroy() 를 두 번
호출부가 포인터를 NULL 로 만들게 _destroy(obj_t **) 로 받는 것도 방법
회차별 진행
회차
변형
보는 것
순서
1회차
A 설정 파일 로더
① 요구사항서 ② pseudo code 전체
1. pseudo 를 한 줄씩 코드로 바꾼다. 2. ③ 테스트와 실패 주입을 실행한다.
2회차
B 세션 컨텍스트 생성기
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. 번호만 보고 쓴다. 2. ③ 테스트와 실패 주입을 실행한다.
3회차
C 두 파일 병합기
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 친다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
1. 요구사항서
1-A. 변형 A — 설정 파일 로더
요구사항서 (발췌)
key=value 형식 설정 파일을 읽어 설정 객체를 만든다.
한 줄 최대 255자.
# 로 시작하는 줄과 빈 줄은 무시.
key 는 최대 31자, value 는 최대 127자.
앞뒤 공백은 제거.
같은 key 가 두 번 나오면 나중 것이 이긴다.
항목 개수 상한은 없다 (동적 확장).
필수 키: listen_port(165535), worker_num(164).
없으면 로딩 실패.
파일이 없으면 RET_IO_ERROR, 형식 위반은 RET_BAD_FORMAT, 할당 실패는 RET_NO_MEM.
성공하면 *out_bytes 에 쓴 바이트 수를 넣는다.
실패하면 *out_bytes = 0 이다.
실패하면 dst 파일을 지운다 (반쯤 쓴 파일이 남지 않는다).
dst 는 쓰기 파일이므로 fclose 실패도 결과에 반영한다 (SAFE_FCLOSE_RET).
dst 가 a 또는 b 와 같은 경로면 거절한다.
에러 코드 규칙 (C)
상황
리턴코드
로그
인자가 NULL, dst 가 a / b 와 같은 경로
RET_INVALID_ARG
가드가 남긴다
파일을 열 수 없음, 읽기·쓰기·fclose 실패
RET_IO_ERROR
O (LOG_ERRNO)
버퍼 할당 실패
RET_NO_MEM
O
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 d3_resource/src/*.c 에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. cfg.c — Level 1 (1회차, 전부 본다)
/* cfg.c *//* [ ] 1. 상수와 구조체 * CFG_KEY_MAX 31 / CFG_VAL_MAX 127 / CFG_LINE_MAX 255 * cfg_item_t { key[+1], val[+1] } * struct cfg_s { items, num, cap } (헤더에는 typedef 만 노출 = 불투명) *//* [ ] 2. static char *trim(char *s) — 앞뒤 공백 제거 (제자리) *//* [ ] 3. static int cfg_put(cfg_t *cfg, const char *key, const char *val) * 가드 3개 * 이미 있는 key 면 val 만 덮어쓴다 (나중 것이 이긴다) * num == cap 이면 확장 * new_cap = (cap == 0) ? 8 : cap * 2 * tmp = realloc(items, ...) ← tmp 로 받는다 * NULL 이면 RET_NO_MEM (원본은 그대로) * 성공했을 때만 items / cap 갱신 * 새 항목은 snprintf 로 복사, num++ *//* [ ] 4. cfg_get_str / cfg_get_int * 가드 → 항목 순회 → 없으면 RET_NOT_FOUND * get_int 는 strtol + endptr + errno(ERANGE) 로 검사, 숫자가 아니면 RET_BAD_FORMAT *//* [ ] 5. int cfg_create(const char *path, cfg_t **out_cfg) * ① int ret = RET_FAIL * ② cfg / fp / line 을 NULL 로 선언 * ③ CHK_PTR(path), CHK_PTR(out_cfg) → 즉시 return * *out_cfg = NULL * ④ cfg = CALLOC(...) → CHK_ALLOC_GOTO * line = CALLOC(...) → CHK_ALLOC_GOTO * fp = fopen(path, "r") → 실패면 LOG_ERRNO, CHK_COND_GOTO(IO_ERROR) * ⑤ fgets 루프 * 개행 제거 → trim → 빈 줄 / '#' 줄은 건너뛴다 * '=' 가 없으면 BAD_FORMAT * key / val 을 trim, 길이 검사 (key 빈 문자열 / 31 초과 / val 127 초과 → BAD_FORMAT) * CHK_RET_GOTO(cfg_put(...)) * ⑥ 필수 키: listen_port(1~65535), worker_num(1~64) * 없으면 NOT_FOUND, 범위 밖이면 BAD_FORMAT * ⑦ 소유권 이전 * *out_cfg = cfg; * cfg = NULL; ← 이 한 줄이 심장 * ret = RET_OK; * ⑧ CLEANUP (잡은 역순) * SAFE_FCLOSE(fp) → SAFE_FREE(line) → cfg_destroy(cfg) * return ret *//* [ ] 6. void cfg_destroy(cfg_t *cfg) * NULL 이면 return / items 해제 / 객체 자체도 free */
2-B. sess.c — Level 2 (2회차, 번호와 제목만 본다)
/* sess.c *//* [ ] 1. struct sess_s { buf, buf_size, names, name_cnt, mu } *//* [ ] 2. sess_destroy — NULL 안전, mutex destroy → 이름 해제(안쪽부터) → 이름 배열 → 버퍼 → 객체 *//* [ ] 3. sess_create ① ~ ⑧ * ① ret = RET_FAIL ② 포인터 NULL ③ 가드 / *out = NULL * ④ 객체 → 버퍼 → 이름 배열 + 각 이름(XSTRDUP) → mutex(MUTEX_INIT) * ⑤ 소유권 이전 (*out = s; s = NULL;) * ⑥ CLEANUP: sess_destroy(s) ← 부분적으로 만든 것도 한 함수로 정리 *//* [ ] 4. sess_name / sess_buf_size / sess_lock / sess_unlock */
2-C. merge.c — Level 3 (3회차, 백지)
/* 아무것도 안 본다. * §1-C 요구사항서만 보고 §0 의 모양(①~⑦)으로 친다. * * 막히면 세 가지만 찾아본다. * - fd 는 -1, FILE* 는 NULL 로 선언하는 이유 * - 쓰기 파일의 fclose 실패를 반영하는 매크로 (SAFE_FCLOSE_RET) * - 실패했을 때 dst 를 지우는 위치 (CLEANUP 안, ret != RET_OK 일 때) */
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 로 SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
그래도 안 되면 모범답안의 해당 단계 하나만 본다.
3. 테스트 코드 — 이게 통과하면 끝
d3_resource/src/cfg.h cfg.c 변형 Ad3_resource/src/sess.h sess.c 변형 Bd3_resource/src/merge.h merge.c 변형 Cd3_resource/src/test_alloc.h 할당 가로채기 (3-0)d3_resource/test/test_cfg.c 변형 A 시험 (3-0 매크로 + 3-A + main)d3_resource/test/test_sess.c 변형 B 시험 (3-0 매크로 + 3-B + main)d3_resource/test/test_merge.c 변형 C 시험 (3-0 매크로 + 3-C + main)
이 드릴의 진짜 시험은 실패 주입(Fault Injection) 이다
정상 동작은 쉽다.
N번째 malloc 이 실패했을 때 누수가 0인지를 봐야 한다.
시험은 정상 / 경계 / NULL 세 가지에 실패 주입이 더해진다.
3-0. 공통 매크로와 할당 가로채기
/* 시험 파일 공통 머리 */#include <stdio.h>#include <stdlib.h>#include <string.h>#include "common.h"#include "log.h"log_level_t g_log_level = LOG_LV_ERR; /* 실패 주입 때 로그가 쏟아지지 않게 */int g_alloc_fail_at = -1;int g_alloc_count = 0;static int g_fail = 0;#define T_EQ_INT(_got, _exp) \ do { \ long long _g = (long long)(_got), _e = (long long)(_exp); \ if (_g != _e) { g_fail++; \ printf(" X %s:%d got=%lld exp=%lld\n", \ __func__, __LINE__, _g, _e); } \ } while (0)#define T_EQ_STR(_got, _exp) \ do { \ if (strcmp((_got), (_exp)) != 0) { g_fail++; \ printf(" X %s:%d got=\"%s\" exp=\"%s\"\n", \ __func__, __LINE__, (_got), (_exp)); } \ } while (0)
realloc 실패 시 원본 포인터를 잃어버린다 → 확정 누수. tmp 로 받고 성공했을 때만 대입
CLEANUP 앞에서 return ret; 을 추가로 함
그 경로만 해제를 건너뛴다. 출구는 하나
해제 순서를 잡은 순서대로
버퍼를 먼저 free 하고 그 버퍼를 쓰는 fclose 후처리를 하면 use-after-free
_destroy() 안에서 멤버만 free 하고 객체 자체를 안 함
객체 크기만큼 매번 샌다
성공했는데 ret 을 안 바꿈
RET_FAIL 로 시작했으니 정상인데 실패로 리턴된다. 제일 흔한 자책골
goto CLEANUP 이 변수 선언을 건너뜀
-Wjump-misses-init / C++ 에서는 컴파일 에러. 선언은 전부 함수 맨 위
(B) CLEANUP 이 mutex 를 init 도 안 한 채 destroy 함
정의되지 않은 동작. sess_destroy 가 “init 됐는지”를 알 수 있게 mutex 를 포인터로 두고 NULL 로 구분한다
(B) 이름 배열은 해제하고 안쪽 문자열은 안 함
이름 개수만큼 샌다. 안쪽 → 바깥 순서로 해제한다
(C) 쓰기 파일을 SAFE_FCLOSE 로 닫음
디스크 가득 참 같은 쓰기 오류가 fclose 에서 드러나는데 성공으로 보고한다. SAFE_FCLOSE_RET
(C) 실패 경로에서 dst 를 안 지움
반쯤 쓴 파일이 남아 다음 실행이 그걸 읽는다
(C) fd 를 0 으로 초기화
0 은 표준입력이다. SAFE_CLOSE 가 stdin 을 닫아 버린다. -1 로 초기화한다
5. 모범답안 (변형 A)
다 치고 나서 열 것 — cfg.c 핵심부
#define CFG_KEY_MAX 31#define CFG_VAL_MAX 127#define CFG_LINE_MAX 255typedef struct { char key[CFG_KEY_MAX + 1]; char val[CFG_VAL_MAX + 1]; } cfg_item_t;struct cfg_s { /* 불투명: 헤더에는 typedef 만 노출 */ cfg_item_t *items; size_t num; size_t cap;};/* ── 항목 추가: 중복 키는 덮어쓰기, 모자라면 2배 확장 ───────── */static int cfg_put(cfg_t *cfg, const char *key, const char *val){ size_t i = 0; cfg_item_t *tmp = NULL; CHK_PTR(cfg); CHK_PTR(key); CHK_PTR(val); for (i = 0; i < cfg->num; i++) { /* 나중 것이 이긴다 */ if (strcmp(cfg->items[i].key, key) == 0) { snprintf(cfg->items[i].val, sizeof(cfg->items[i].val), "%s", val); return RET_OK; } } if (cfg->num == cfg->cap) { size_t new_cap = (cfg->cap == 0) ? 8 : cfg->cap * 2; tmp = realloc(cfg->items, new_cap * sizeof(*tmp)); /* ★ tmp 로 받는다 */ if (tmp == NULL) { /* 원본은 그대로 살아있다 */ LOG_ERR("realloc %zu failed", new_cap); return RET_NO_MEM; } cfg->items = tmp; cfg->cap = new_cap; } snprintf(cfg->items[cfg->num].key, CFG_KEY_MAX + 1, "%s", key); snprintf(cfg->items[cfg->num].val, CFG_VAL_MAX + 1, "%s", val); cfg->num++; return RET_OK;}/* ── 생성: 소유권을 호출부로 넘긴다 ─────────────────────────── */int cfg_create(const char *path, cfg_t **out_cfg){ int ret = RET_FAIL; /* ① */ cfg_t *cfg = NULL; /* ② */ FILE *fp = NULL; char *line = NULL; CHK_PTR(path); /* ③ 자원 잡기 전 → 즉시 return */ CHK_PTR(out_cfg); *out_cfg = NULL; /* 실패해도 호출부가 쓰레기를 안 보게 */ cfg = CALLOC(1, sizeof(*cfg)); /* ④ 여기부터 goto */ CHK_ALLOC_GOTO(cfg, ret, CLEANUP); line = CALLOC(1, CFG_LINE_MAX + 2); CHK_ALLOC_GOTO(line, ret, CLEANUP); fp = fopen(path, "r"); CHK_COND_GOTO(fp != NULL, RET_IO_ERROR, ret, CLEANUP); while (fgets(line, CFG_LINE_MAX + 2, fp) != NULL) { char *eq = NULL, *key = NULL, *val = NULL; line[strcspn(line, "\r\n")] = '\0'; /* 개행 제거 */ key = trim(line); if (key[0] == '\0' || key[0] == '#') continue; /* 빈 줄 / 주석 */ eq = strchr(key, '='); CHK_COND_GOTO(eq != NULL, RET_BAD_FORMAT, ret, CLEANUP); *eq = '\0'; val = trim(eq + 1); key = trim(key); CHK_COND_GOTO(key[0] != '\0', RET_BAD_FORMAT, ret, CLEANUP); CHK_COND_GOTO(strlen(key) <= CFG_KEY_MAX, RET_BAD_FORMAT, ret, CLEANUP); CHK_COND_GOTO(strlen(val) <= CFG_VAL_MAX, RET_BAD_FORMAT, ret, CLEANUP); CHK_RET_GOTO(cfg_put(cfg, key, val), ret, CLEANUP); } /* 필수 키 검사 */ { int port = 0, worker = 0; CHK_RET_GOTO(cfg_get_int(cfg, "listen_port", &port), ret, CLEANUP); CHK_RET_GOTO(cfg_get_int(cfg, "worker_num", &worker), ret, CLEANUP); CHK_COND_GOTO(port >= 1 && port <= 65535, RET_BAD_FORMAT, ret, CLEANUP); CHK_COND_GOTO(worker >= 1 && worker <= 64, RET_BAD_FORMAT, ret, CLEANUP); } *out_cfg = cfg; /* ★ 소유권 이전 */ cfg = NULL; /* ★ 아래 CLEANUP 이 해제하지 않도록 끊는다 */ ret = RET_OK; /* ⑤ */CLEANUP: /* ⑥ 잡은 역순 */ SAFE_FCLOSE(fp); SAFE_FREE(line); cfg_destroy(cfg); /* 성공 시엔 NULL 이므로 아무 일도 안 한다 */ return ret;}void cfg_destroy(cfg_t *cfg){ if (cfg == NULL) return; /* ★ NULL 안전 */ SAFE_FREE(cfg->items); free(cfg); /* 객체 자체도 반드시 */}
눈여겨볼 점 4가지
cfg = NULL; 한 줄이 이 함수의 심장이다. 성공 경로에서 소유권을 넘긴 뒤 로컬 포인터를 끊어야, 같은 CLEANUP 라벨을 성공·실패가 같이 쓸 수 있다.
realloc 은 tmp 로 받는다. 바로 대입하면 실패 시 원본을 잃는다.
cfg_destroy 를 CLEANUP 에서 쓰니 부분적으로 만들어진 객체도 한 함수로 정리된다.
해제 코드를 두 군데 쓰지 않는다.
new_cap * sizeof(*tmp) 에 곱셈 오버플로우 검사가 없다 — 의도적으로 괜찮다.new_cap은 파일 안 줄 수만큼만 1→8→16→32… 로 내부에서 2배씩 늘어나는 값이라, 외부 입력이 곱셈에 직접 실리는 SD17류 시나리오와 다르다(SIZE_MAX 근처까지 가려면 그전에 파일 읽기가 끝난다).
count가 파일·네트워크·프로토콜 필드처럼 외부에서 그대로 들어오는 경우라면 이 가정이 깨지므로, SD17. 정수 오버플로우처럼 직접 검사하거나 [C] 개수 필드 + 포인터의 포인터 5-2절의 calloc으로 바꿔야 한다.
위 핵심부를 컴파일되게 하는 나머지 — cfg.h · include · trim · getter
아래를 cfg.c 맨 위(include·trim)와 맨 아래(getter)에 붙이면 §3 의 시험이 그대로 PASS 한다.
/* cfg.h */#ifndef CFG_H#define CFG_Htypedef struct cfg_s cfg_t;int cfg_create(const char *path, cfg_t **out_cfg);void cfg_destroy(cfg_t *cfg);int cfg_get_str(const cfg_t *cfg, const char *key, const char **out);int cfg_get_int(const cfg_t *cfg, const char *key, int *out);#endif/* cfg.c 맨 위 */#include <ctype.h>#include <limits.h>#include "common.h"#include "log.h"#include "safe.h"#include "guard.h"#include "test_alloc.h" /* CALLOC — 실패 주입 */#include "cfg.h"static char *trim(char *s) /* 앞뒤 공백 제거. 제자리에서 자르고 시작 위치를 돌려준다 */{ char *e = NULL; while (*s != '�' && isspace((unsigned char)*s)) s++; e = s + strlen(s); while (e > s && isspace((unsigned char)e[-1])) e--; *e = '�'; return s;}/* cfg.c 맨 아래 (struct cfg_s 정의 뒤) */int cfg_get_str(const cfg_t *cfg, const char *key, const char **out){ size_t i = 0; CHK_PTR(cfg); CHK_PTR(key); CHK_PTR(out); for (i = 0; i < cfg->num; i++) { if (strcmp(cfg->items[i].key, key) == 0) { *out = cfg->items[i].val; return RET_OK; } } return RET_NOT_FOUND;}int cfg_get_int(const cfg_t *cfg, const char *key, int *out){ const char *s = NULL; char *end = NULL; long v = 0; CHK_PTR(out); CHK_RET(cfg_get_str(cfg, key, &s)); errno = 0; v = strtol(s, &end, 10); if (end == s || *end != '�' || errno != 0 || v < INT_MIN || v > INT_MAX) { LOG_ERR("%s is not an integer: %s", key, s); return RET_BAD_FORMAT; } *out = (int)v; return RET_OK;}